route.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import fs from "node:fs";
  2. import fsp from "node:fs/promises";
  3. import path from "node:path";
  4. import { Readable } from "node:stream";
  5. import { getSession } from "@/lib/auth/session";
  6. import { canAccessBranch } from "@/lib/auth/permissions";
  7. import {
  8. withErrorHandling,
  9. badRequest,
  10. unauthorized,
  11. forbidden,
  12. notFound,
  13. ApiError,
  14. } from "@/lib/api/errors";
  15. import { mapStorageReadError } from "@/lib/api/storageErrors";
  16. export const dynamic = "force-dynamic";
  17. export const runtime = "nodejs";
  18. const BRANCH_RE = /^NL\d+$/;
  19. const YEAR_RE = /^\d{4}$/;
  20. const MONTH_RE = /^(0[1-9]|1[0-2])$/;
  21. const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/;
  22. function getNasRootOrThrow() {
  23. const root = process.env.NAS_ROOT_PATH;
  24. if (!root) {
  25. throw new ApiError({
  26. status: 500,
  27. code: "FS_STORAGE_ERROR",
  28. message: "Internal server error",
  29. });
  30. }
  31. return root;
  32. }
  33. function isSafeFilename(name) {
  34. if (typeof name !== "string") return false;
  35. const trimmed = name.trim();
  36. if (!trimmed) return false;
  37. // Reject special path segments
  38. if (trimmed === "." || trimmed === "..") return false;
  39. // Reject any path separators (defense-in-depth)
  40. if (trimmed.includes("/") || trimmed.includes("\\")) return false;
  41. // Reject control chars (header injection)
  42. if (/[\r\n\t]/.test(trimmed)) return false;
  43. // Reject quotes to keep Content-Disposition predictable/safe
  44. if (trimmed.includes('"')) return false;
  45. // Ensure it's a basename (no sneaky segments)
  46. if (path.basename(trimmed) !== trimmed) return false;
  47. return true;
  48. }
  49. function isPdfFilename(name) {
  50. return typeof name === "string" && name.toLowerCase().endsWith(".pdf");
  51. }
  52. function validateParamsOrThrow({ branch, year, month, day, filename }) {
  53. if (!BRANCH_RE.test(branch)) {
  54. throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", {
  55. branch,
  56. });
  57. }
  58. if (!YEAR_RE.test(year)) {
  59. throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year });
  60. }
  61. if (!MONTH_RE.test(month)) {
  62. throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month });
  63. }
  64. if (!DAY_RE.test(day)) {
  65. throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day });
  66. }
  67. if (!isSafeFilename(filename)) {
  68. throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", {
  69. filename,
  70. });
  71. }
  72. if (!isPdfFilename(filename)) {
  73. throw badRequest(
  74. "VALIDATION_FILE_EXTENSION",
  75. "Only PDF files are allowed",
  76. { filename }
  77. );
  78. }
  79. }
  80. function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
  81. const rootAbs = path.resolve(root);
  82. const absPath = path.resolve(rootAbs, branch, year, month, day, filename);
  83. // Ensure the resolved path stays within NAS_ROOT_PATH
  84. const rel = path.relative(rootAbs, absPath);
  85. if (rel.startsWith("..") || path.isAbsolute(rel)) {
  86. throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", {
  87. branch,
  88. year,
  89. month,
  90. day,
  91. filename,
  92. });
  93. }
  94. return absPath;
  95. }
  96. /**
  97. * Content-Disposition helper (Unicode-safe).
  98. *
  99. * Problem:
  100. * - Node's Web Response headers require ByteString-compatible values.
  101. * - Unicode characters (e.g. "€") in `filename="..."` can crash the response creation.
  102. *
  103. * Solution:
  104. * - Provide an ASCII fallback via `filename="..."`.
  105. * - Provide the real UTF-8 name via RFC 5987: `filename*=UTF-8''...`.
  106. */
  107. function stripDiacritics(input) {
  108. return String(input)
  109. .normalize("NFKD")
  110. .replace(/[\u0300-\u036f]/g, "");
  111. }
  112. function toAsciiFallbackFilename(filename) {
  113. // Keep it predictable and safe for headers: ASCII only.
  114. // We also keep the .pdf extension if possible.
  115. const raw = stripDiacritics(filename);
  116. const ascii = raw
  117. .replace(/[^\x20-\x7E]/g, "_") // replace non-ASCII with underscore
  118. .replace(/\s+/g, " ") // collapse whitespace
  119. .replace(/_+/g, "_") // collapse underscores
  120. .trim();
  121. if (!ascii) return "download.pdf";
  122. if (!ascii.toLowerCase().endsWith(".pdf")) return `${ascii}.pdf`;
  123. return ascii;
  124. }
  125. function encodeRFC5987ValueChars(str) {
  126. // RFC 5987 encoding for header parameters:
  127. // Use percent-encoded UTF-8 bytes and additionally encode a few chars that
  128. // encodeURIComponent leaves as-is but can be problematic in headers.
  129. return encodeURIComponent(str)
  130. .replace(/['()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
  131. .replace(/\*/g, "%2A");
  132. }
  133. function buildContentDisposition(filename, asAttachment) {
  134. const type = asAttachment ? "attachment" : "inline";
  135. const fallback = toAsciiFallbackFilename(filename);
  136. const encoded = encodeRFC5987ValueChars(filename);
  137. return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
  138. }
  139. /**
  140. * GET /api/files/:branch/:year/:month/:day/:filename
  141. *
  142. * Query (optional):
  143. * - download=1 | download=true => Content-Disposition: attachment
  144. * - default => inline
  145. */
  146. export const GET = withErrorHandling(
  147. async function GET(request, ctx) {
  148. const session = await getSession();
  149. if (!session) {
  150. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  151. }
  152. const { branch, year, month, day, filename } = await ctx.params;
  153. const missing = [];
  154. if (!branch) missing.push("branch");
  155. if (!year) missing.push("year");
  156. if (!month) missing.push("month");
  157. if (!day) missing.push("day");
  158. if (!filename) missing.push("filename");
  159. if (missing.length > 0) {
  160. throw badRequest(
  161. "VALIDATION_MISSING_PARAM",
  162. "Missing required route parameter(s)",
  163. { params: missing }
  164. );
  165. }
  166. if (!canAccessBranch(session, branch)) {
  167. throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
  168. }
  169. validateParamsOrThrow({ branch, year, month, day, filename });
  170. const root = getNasRootOrThrow();
  171. const absPath = resolvePdfPathOrThrow({
  172. root,
  173. branch,
  174. year,
  175. month,
  176. day,
  177. filename,
  178. });
  179. const details = { branch, year, month, day, filename };
  180. let stat;
  181. try {
  182. stat = await fsp.stat(absPath);
  183. } catch (err) {
  184. throw await mapStorageReadError(err, { details });
  185. }
  186. if (!stat.isFile()) {
  187. throw notFound("FS_NOT_FOUND", "Not found", details);
  188. }
  189. const { searchParams } = new URL(request.url);
  190. const download = (searchParams.get("download") || "").toLowerCase();
  191. const asAttachment = download === "1" || download === "true";
  192. const contentDisposition = buildContentDisposition(filename, asAttachment);
  193. const nodeStream = fs.createReadStream(absPath);
  194. const webStream = Readable.toWeb(nodeStream);
  195. return new Response(webStream, {
  196. status: 200,
  197. headers: {
  198. "Content-Type": "application/pdf",
  199. "Content-Disposition": contentDisposition,
  200. "Content-Length": String(stat.size),
  201. "Cache-Control": "no-store",
  202. "X-Content-Type-Options": "nosniff",
  203. },
  204. });
  205. },
  206. { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" }
  207. );